Repository navigation
Read notebooks saved in the nbformat 3 layout - #2612
Chen Yufeiyang (feiiiiii5) wants to merge 2 commits into
Conversation
nbformat 3 (IPython 3, Jupyter 4.0) nests cells under worksheets[*].cells
rather than a top-level `cells` list, and stores code cell text under
`input` instead of `source`. Reading only the nbformat 4 layout meant
notebook_content.get("cells", [])
returned [] for such a file, so every cell was skipped and the converter
returned an empty document:
convert(nb3).markdown -> ''
CLI exit code -> 0, stderr empty
An accepted notebook silently converting to nothing is never the right
answer, so collect cells from whichever layout the document uses and fall
back to `input` for the cell text nbformat 3 stores there. nbformat 4 is
unchanged, and the same notebook in either layout now produces identical
Markdown.
XU (kokokoXUY)
left a comment
There was a problem hiding this comment.
Review: independent verification of nbformat 3 support
Thanks Chen Yufeiyang (@feiiiiii5) — I reproduced the silent empty-document bug and this fix locally, with the base (4cc9fa1) and head (ad145f0) trees side by side, then ran the new tests, the package suite and the formatter check on both. The fix does what the description says for the layouts it targets; the notes below are about the parts of the nbformat 3 layout that are still dropped, plus two incidental behaviour changes worth pinning with a test.
1. The reported bug reproduces, and the new cell walker fixes it
Each row is a probe notebook converted through IpynbConverter().convert(...) on both trees; len is the produced markdown length.
| Input | base | head |
|---|---|---|
v3, worksheets[0].cells, list-shaped input/source |
len=0, title=None |
len=64, title='Quarterly notes' |
v3, single-string input/source |
len=0, title=None |
len=64, title=None (see §3) |
| v3, two worksheets, one code cell each | len=0 |
len=51 |
v3 through MarkItDown().convert_stream(..., extension=".ipynb") |
len=0 |
len=64 |
| v4 control (unchanged on both sides) | len=64, title='Quarterly notes' |
identical output |
accepts() does claim an application/json stream whose text contains nbformat and nbformat_minor, so the empty result really was returned as a successful conversion rather than falling through to the text converter — I could not find a path where the plain-text fallback rescued it.
2. A notebook written by nbformat's own v3 writer still loses its heading cells
This is the case I would most like covered before merge, because it is the file an upstream tool actually produces. I built the notebook with the nbformat package itself (v3.new_notebook, v3.new_heading_cell("Quarterly notes", level=1), v3.new_code_cell, v3.new_text_cell("markdown", ...)), wrote it with nbformat.write(nb, file, version=3) and validated it with nbformat.validate:
top-level keys: ['metadata', 'nbformat', 'nbformat_minor', 'worksheets']
{'cell_type': 'heading', 'level': 1, 'source': ['Quarterly notes']}
{'cell_type': 'code', 'input': ["print('hello')"], 'outputs': [], 'prompt_number': 1}
{'cell_type': 'markdown', 'source': ['Revenue grew.\n']}
{'cell_type': 'code', 'input': ['2 + 2'], 'outputs': [], 'prompt_number': 2}
Converting that file:
base: len=0 title=None
head: len=64 title=None -> code and markdown cells kept, the heading cell is dropped
The same content upgraded to nbformat 4 with nbformat.reads(raw, as_version=4) gives cell types ['markdown', 'code', 'markdown', 'code'] with the first source '# Quarterly notes', and converting that v4 form yields len=83, title='Quarterly notes'. The 19 missing characters are exactly the heading. The format description is explicit that this is v3-only content: "Heading cells have been removed in favor of simple headings in markdown. Changed in version nbformat: 4.0."
heading is a first-class nbformat 3 cell type ({"cell_type": "heading", "level": 1, "source": ...}), so the loop in _convert() currently falls through all three branches and the cell disappears silently. Two consequences:
- A v3 notebook whose content lives in heading cells still converts to an empty document with exit code 0 — the exact symptom this PR fixes for code and markdown cells. Probe: a single
headingcell withsource: ["Section A\n", "more text\n"]→ headlen=0. - Titles that used to come from the first
#line of a markdown cell are often in a heading cell in v3 files, which is why the title isNoneabove.
If you want to keep the change minimal, mapping a heading to markdown text ("#" * cell.get("level", 1) + " " + "".join(source_lines)) would make the v3 path match the v4 result for the fixture above; at minimum it deserves a test so the gap is recorded rather than silent.
3. A schema-valid single-string source/input still loses the title (pre-existing, but adjacent to this change)
The new fallback is deliberately shape-agnostic — "".join(source_lines) works for a string and for a list — but the title extraction right below it is not:
for line in source_lines:
if line.startswith("# "):When source_lines is a plain string, this iterates characters, so "# Quarterly notes" never matches and the title is lost. Both shapes are valid on disk; the format description says you "must allow multi-line string fields to be either a string or list of strings". Probe with identical content:
source: ["# Quarterly notes\n", "\n", "Revenue grew.\n"]→title='Quarterly notes'source: "# Quarterly notes\n\nRevenue grew.\n"→title=None(same on the v4 path, base and head, so this is not introduced here)
Normalising once (if isinstance(source_lines, str): source_lines = [source_lines]) would fix the title path and remove the character-iteration subtlety for all cell types.
4. Two incidental robustness gains that the description does not mention
{"cells": null, "worksheets": [...]}— base raisesFileConversionException: Error converting .ipynb file: 'NoneType' object is not iterable; head returns the worksheet content (len=16). Theis not Nonecheck is what makes the difference; that is a real crash fix worth a line in the description and a test.- nbformat 2 files use the same
worksheets[*].cells+inputlayout, so they convert now as well (probe:nbformat: 2code cell → baselen=0, headlen=26). The docstring says nbformat 3; nbformat 2 works too, untested.
5. One precedence nuance
{"cells": [], "worksheets": [non-empty]} still converts to nothing, because an empty top-level list wins over the fallback, while null falls back. A file with both keys is malformed either way, so this is not a defect — but since the two branches differ, one test pinning the intended precedence (and a comment on the cells is not None line) would keep the next reader from "fixing" it in the other direction.
6. Test, suite and lint evidence
- New file on head:
8 passed. Same file copied into the base tree:6 failed, 2 passed— exactly the six you list, withtest_nbformat_3_title_from_metadata_still_winsandtest_nbformat_4_is_unchangedgreen on both sides. - Package suite with the same test set on both sides and only the source tree swapped (
pytest packages/markitdown/tests,test_cu.pyignored becauseazure-ai-contentunderstandingis not installed here): base37 failed, 957 passed, 3 skipped→ head31 failed, 963 passed, 3 skipped. Diffing theFAILEDlines gives exactly the six new tests — no other test changes state in either direction. - The 31 remaining failures are the pre-existing environment ones:
test_cli_vectors.py(21),test_cli_misc.py(7),test_docintel.py(2),test_module_misc.py(1). - Existing ipynb tests in
test_module_misc.py(-k ipynb):6 passedon both sides. black 23.7.0 --check(the version pinned in.pre-commit-config.yaml) on_ipynb_converter.pyand the new test file: exit 0.- CI on this head has not run: only
license/clacompleted; thetestsandpre-commitworkflow runs areaction_required, and the PR showsmergeable_state=blocked. So nothing in §1–§3 was verified by CI — the numbers above are from a local run on Windows, CPython 3.13.12.
7. Out of scope, no regression
Code-cell outputs are ignored for both layouts (v3 stream/pyout shapes never reach the renderer), and the code fence still gets a blank line before the closing fence when the cell text ends in "\n". Both are unchanged by this PR; I mention them only so they are not mistaken for new behaviour.
Disclosure: this review was prepared with an AI coding assistant (DeepSeek) under my direction; every probe, test run and command above was executed locally by me, and the review was posted from my account.
|
Thanks for the thorough independent verification – happy that the probe table matches my own numbers. Addressed in d7e13ff:
Tests: |
A notebook saved in the nbformat 3 layout — IPython 3, Jupyter 4.0 — converts to an empty document with exit code 0. Every code cell, every markdown cell, the title: all gone, with no warning.
What happens
nbformat 3 nests cells under
worksheets[*].cellsand stores code cell text underinput. nbformat 4 uses a flat top-levelcellslist andsourceeverywhere. The converter only knew the second layout:The nbformat-4-only read
So for an nbformat 3 file
.get("cells", [])returns[], the loop body never runs, and"\n\n".join([])is"":The converter's
accepts()claims the file — it only checks thatnbformatandnbformat_minorappear in the text, which they do in both versions — so the plain-text fallback never gets a chance either. The result looks like a successful conversion of an empty notebook.The fix
Collect cells from whichever layout the document uses, and fall back to
inputfor the cell text nbformat 3 puts there:The cell collector
and in the loop:
The text fallback
Both fallbacks are ordered so nbformat 4 is untouched: a top-level
cellskey wins, andsourcewins overinputwhen both are present.Validation
packages/markitdown/tests/test_ipynb_nbformat3.py, 8 tests:Test output
The two that pass either way are the controls: an nbformat 4 notebook converts exactly as before, and a title in nbformat 3
metadatastill wins over one found in a cell. The strongest of the new tests asserts that the same notebook in either layout produces byte-identical Markdown, which is the invariant rather than a snapshot.Whole suite, same selection on both sides:
The 7 failures are pre-existing and unrelated —
test_cli_misc.py::test_cu_*all fail withModuleNotFoundError: No module named 'azure'because my venv lacks the Azure extras. I skippedtest_pdf.py,test_youtube.py,test_cu.py,test_docintel.py,test_outlook_msg.py,test_image.pyand the two vector suites, because the vector tests reach the network and exceeded my time budget — so I have not run those.black --checkis clean on both files, which is the only lint this repo'spre-commitruns.A note on scope
This repo has a large open-PR queue and
IpynbConverterhas several PRs against it already (#2549, #2518, #2511, #2473, #2463, #2443, #2239, #2170). I read the titles and none of them is about the nbformat 3 layout — they are all nbformat 4 concerns: string-versus-listsource, the notebook's own language for fences, dynamic fences, title extraction. #2473 ("preserve JSON documents that mention notebook fields") touches the same function but a different failure, so this should merge independently; I would rather say so than have you find the overlap in review.Separately, and not in this PR because each is already claimed: trailing Unicode whitespace is stripped by the result normaliser in
_markitdown.py(line.rstrip()eats NBSP and friends; #2559, #2446 and #2529 all touch that expression), and pipes inside table cells are not escaped in several converters (#2608, #2465, #2441, #2438, #2436, #2520). I mention them only so they are not lost.Also in this branch: nbformat 3 heading cells and single-string source
A review probe found two adjacent nbformat 3 gaps on the same converter, both now covered:
headingis a first-class v3 cell type; it now maps to a markdown#line instead of being dropped, so a notebook whose content lives in heading cells no longer converts to an empty document, and its first heading becomes the title (same shape a v4 upgrade produces).source/inputgiven as a plain string was iterated character-by-character when matching the#title rule, losing the title; it is now normalised to lines first.{"cells": null, "worksheets": [...]}now falls back to the worksheet cells instead of raising, with tests pinning that and the[]vsnullprecedence.Tests:
packages/markitdown/tests/test_ipynb_nbformat3.py11 passed, and the ipynb slice oftest_module_misc.pypasses (6 passed; widertest_module_misc.pyuntouched). black 23.7.0-compliant per repo pinning.